/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

import * as assert from 'assert';
import * as sinon from 'sinon';
import { CancellationTokenSource, Disposable, EventEmitter, ExtensionContext, NotebookCellKind, NotebookDocumentChangeEvent, NotebookDocumentWillSaveEvent, NotebookEdit, NotebookRange, TextDocumentSaveReason, workspace, type CancellationToken, type NotebookCell, type NotebookDocument, type WorkspaceEdit, type WorkspaceEditMetadata } from 'vscode';
import { activate } from '../notebookModelStoreSync';

[true, false].forEach(useCustomPropertyInMetadata => {
	suite(`Notebook Model Store Sync (${useCustomPropertyInMetadata ? 'with custom metadata (standard behaviour)' : 'without custom metadata'})`, () => {
		let disposables: Disposable[] = [];
		let onDidChangeNotebookDocument: EventEmitter<NotebookDocumentChangeEvent>;
		let onWillSaveNotebookDocument: AsyncEmitter<NotebookDocumentWillSaveEvent>;
		let notebook: NotebookDocument;
		let token: CancellationTokenSource;
		let editsApplied: WorkspaceEdit[] = [];
		let pendingPromises: Promise<void>[] = [];
		let cellMetadataUpdates: NotebookEdit[] = [];
		let applyEditStub: sinon.SinonStub<[edit: WorkspaceEdit, metadata?: WorkspaceEditMetadata | undefined], Thenable<boolean>>;
		setup(() => {
			disposables = [];
			notebook = {
				notebookType: '',
				metadata: {}
			} as NotebookDocument;
			sinon.stub(workspace, 'getConfiguration').callsFake((section, scope) => {
				if (section === 'jupyter') {
					return {
						get: () => {
							return !useCustomPropertyInMetadata;
						}
					};
				} else {
					return (workspace.getConfiguration as any).wrappedMethod.call(workspace, section, scope);
				}
			});
			token = new CancellationTokenSource();
			disposables.push(token);
			sinon.stub(notebook, 'notebookType').get(() => 'jupyter-notebook');
			applyEditStub = sinon.stub(workspace, 'applyEdit').callsFake((edit: WorkspaceEdit) => {
				editsApplied.push(edit);
				return Promise.resolve(true);
			});
			const context = { subscriptions: [] as Disposable[] } as ExtensionContext;
			onDidChangeNotebookDocument = new EventEmitter<NotebookDocumentChangeEvent>();
			disposables.push(onDidChangeNotebookDocument);
			onWillSaveNotebookDocument = new AsyncEmitter<NotebookDocumentWillSaveEvent>();

			sinon.stub(NotebookEdit, 'updateCellMetadata').callsFake((index, metadata) => {
				const edit = (NotebookEdit.updateCellMetadata as any).wrappedMethod.call(NotebookEdit, index, metadata);
				cellMetadataUpdates.push(edit);
				return edit;
			}
			);
			sinon.stub(workspace, 'onDidChangeNotebookDocument').callsFake(cb =>
				onDidChangeNotebookDocument.event(cb)
			);
			sinon.stub(workspace, 'onWillSaveNotebookDocument').callsFake(cb =>
				onWillSaveNotebookDocument.event(cb)
			);
			activate(context);
		});
		teardown(async () => {
			await Promise.allSettled(pendingPromises);
			editsApplied = [];
			pendingPromises = [];
			cellMetadataUpdates = [];
			disposables.forEach(d => d.dispose());
			disposables = [];
			sinon.restore();
		});

		test('Empty cell will not result in any updates', async () => {
			const e: NotebookDocumentChangeEvent = {
				notebook,
				metadata: undefined,
				contentChanges: [],
				cellChanges: []
			};

			onDidChangeNotebookDocument.fire(e);

			assert.strictEqual(editsApplied.length, 0);
		});
		test('Adding cell for non Jupyter Notebook will not result in any updates', async () => {
			sinon.stub(notebook, 'notebookType').get(() => 'some-other-type');
			const cell: NotebookCell = {
				document: {} as any,
				executionSummary: {},
				index: 0,
				kind: NotebookCellKind.Code,
				metadata: {},
				notebook,
				outputs: []
			};
			const e: NotebookDocumentChangeEvent = {
				notebook,
				metadata: undefined,
				contentChanges: [
					{
						range: new NotebookRange(0, 0),
						removedCells: [],
						addedCells: [cell]
					}
				],
				cellChanges: []
			};

			onDidChangeNotebookDocument.fire(e);

			assert.strictEqual(editsApplied.length, 0);
			assert.strictEqual(cellMetadataUpdates.length, 0);
		});
		test('Adding cell will result in an update to the metadata', async () => {
			const cell: NotebookCell = {
				document: {} as any,
				executionSummary: {},
				index: 0,
				kind: NotebookCellKind.Code,
				metadata: {},
				notebook,
				outputs: []
			};
			const e: NotebookDocumentChangeEvent = {
				notebook,
				metadata: undefined,
				contentChanges: [
					{
						range: new NotebookRange(0, 0),
						removedCells: [],
						addedCells: [cell]
					}
				],
				cellChanges: []
			};

			onDidChangeNotebookDocument.fire(e);

			assert.strictEqual(editsApplied.length, 1);
			assert.strictEqual(cellMetadataUpdates.length, 1);
			const newMetadata = cellMetadataUpdates[0].newCellMetadata;
			if (useCustomPropertyInMetadata) {
				assert.deepStrictEqual(newMetadata, { custom: { metadata: {} } });
			} else {
				assert.deepStrictEqual(newMetadata, { metadata: {} });
			}
		});
		test('Add cell id if nbformat is 4.5', async () => {
			if (useCustomPropertyInMetadata) {
				sinon.stub(notebook, 'metadata').get(() => ({ custom: { nbformat: 4, nbformat_minor: 5 } }));
			} else {
				sinon.stub(notebook, 'metadata').get(() => ({ nbformat: 4, nbformat_minor: 5 }));
			}
			const cell: NotebookCell = {
				document: {} as any,
				executionSummary: {},
				index: 0,
				kind: NotebookCellKind.Code,
				metadata: {},
				notebook,
				outputs: []
			};
			const e: NotebookDocumentChangeEvent = {
				notebook,
				metadata: undefined,
				contentChanges: [
					{
						range: new NotebookRange(0, 0),
						removedCells: [],
						addedCells: [cell]
					}
				],
				cellChanges: []
			};

			onDidChangeNotebookDocument.fire(e);

			assert.strictEqual(editsApplied.length, 1);
			assert.strictEqual(cellMetadataUpdates.length, 1);
			const newMetadata = cellMetadataUpdates[0].newCellMetadata || {};
			if (useCustomPropertyInMetadata) {
				assert.strictEqual(Object.keys(newMetadata).length, 1);
				assert.strictEqual(Object.keys(newMetadata.custom).length, 2);
				assert.deepStrictEqual(newMetadata.custom.metadata, {});
				assert.ok(newMetadata.custom.id);
			} else {
				assert.strictEqual(Object.keys(newMetadata).length, 2);
				assert.deepStrictEqual(newMetadata.metadata, {});
				assert.ok(newMetadata.id);
			}
		});
		test('Do not add cell id if one already exists', async () => {
			if (useCustomPropertyInMetadata) {
				sinon.stub(notebook, 'metadata').get(() => ({ custom: { nbformat: 4, nbformat_minor: 5 } }));
			} else {
				sinon.stub(notebook, 'metadata').get(() => ({ nbformat: 4, nbformat_minor: 5 }));
			}
			const cell: NotebookCell = {
				document: {} as any,
				executionSummary: {},
				index: 0,
				kind: NotebookCellKind.Code,
				metadata: useCustomPropertyInMetadata ? {
					custom: {
						id: '1234'
					}
				} : {
					id: '1234'
				},
				notebook,
				outputs: []
			};
			const e: NotebookDocumentChangeEvent = {
				notebook,
				metadata: undefined,
				contentChanges: [
					{
						range: new NotebookRange(0, 0),
						removedCells: [],
						addedCells: [cell]
					}
				],
				cellChanges: []
			};

			onDidChangeNotebookDocument.fire(e);

			assert.strictEqual(editsApplied.length, 1);
			assert.strictEqual(cellMetadataUpdates.length, 1);
			const newMetadata = cellMetadataUpdates[0].newCellMetadata || {};
			if (useCustomPropertyInMetadata) {
				assert.strictEqual(Object.keys(newMetadata).length, 1);
				assert.strictEqual(Object.keys(newMetadata.custom).length, 2);
				assert.deepStrictEqual(newMetadata.custom.metadata, {});
				assert.strictEqual(newMetadata.custom.id, '1234');
			} else {
				assert.strictEqual(Object.keys(newMetadata).length, 2);
				assert.deepStrictEqual(newMetadata.metadata, {});
				assert.strictEqual(newMetadata.id, '1234');
			}
		});
		test('Do not perform any updates if cell id and metadata exists', async () => {
			if (useCustomPropertyInMetadata) {
				sinon.stub(notebook, 'metadata').get(() => ({ custom: { nbformat: 4, nbformat_minor: 5 } }));
			} else {
				sinon.stub(notebook, 'metadata').get(() => ({ nbformat: 4, nbformat_minor: 5 }));
			}
			const cell: NotebookCell = {
				document: {} as any,
				executionSummary: {},
				index: 0,
				kind: NotebookCellKind.Code,
				metadata: useCustomPropertyInMetadata ? {
					custom: {
						id: '1234',
						metadata: {}
					}
				} : {
					id: '1234',
					metadata: {}
				},
				notebook,
				outputs: []
			};
			const e: NotebookDocumentChangeEvent = {
				notebook,
				metadata: undefined,
				contentChanges: [
					{
						range: new NotebookRange(0, 0),
						removedCells: [],
						addedCells: [cell]
					}
				],
				cellChanges: []
			};

			onDidChangeNotebookDocument.fire(e);

			assert.strictEqual(editsApplied.length, 0);
			assert.strictEqual(cellMetadataUpdates.length, 0);
		});
		test('Store language id in custom metadata, whilst preserving existing metadata', async () => {
			if (useCustomPropertyInMetadata) {
				sinon.stub(notebook, 'metadata').get(() => ({
					custom: {
						nbformat: 4, nbformat_minor: 5,
						metadata: {
							language_info: { name: 'python' }
						}
					}
				}));
			} else {
				sinon.stub(notebook, 'metadata').get(() => ({
					nbformat: 4, nbformat_minor: 5,
					metadata: {
						language_info: { name: 'python' }
					}
				}));
			}
			const cell: NotebookCell = {
				document: {
					languageId: 'javascript'
				} as any,
				executionSummary: {},
				index: 0,
				kind: NotebookCellKind.Code,
				metadata: useCustomPropertyInMetadata ? {
					custom: {
						id: '1234',
						metadata: {
							collapsed: true, scrolled: true
						}
					}
				} : {
					id: '1234',
					metadata: {
						collapsed: true, scrolled: true
					}
				},
				notebook,
				outputs: []
			};
			const e: NotebookDocumentChangeEvent = {
				notebook,
				metadata: undefined,
				contentChanges: [],
				cellChanges: [
					{
						cell,
						document: {
							languageId: 'javascript'
						} as any,
						metadata: undefined,
						outputs: undefined,
						executionSummary: undefined
					}
				]
			};

			onDidChangeNotebookDocument.fire(e);

			assert.strictEqual(editsApplied.length, 1);
			assert.strictEqual(cellMetadataUpdates.length, 1);
			const newMetadata = cellMetadataUpdates[0].newCellMetadata || {};
			if (useCustomPropertyInMetadata) {
				assert.strictEqual(Object.keys(newMetadata).length, 1);
				assert.strictEqual(Object.keys(newMetadata.custom).length, 2);
				assert.deepStrictEqual(newMetadata.custom.metadata, { collapsed: true, scrolled: true, vscode: { languageId: 'javascript' } });
				assert.strictEqual(newMetadata.custom.id, '1234');
			} else {
				assert.strictEqual(Object.keys(newMetadata).length, 2);
				assert.deepStrictEqual(newMetadata.metadata, { collapsed: true, scrolled: true, vscode: { languageId: 'javascript' } });
				assert.strictEqual(newMetadata.id, '1234');
			}
		});
		test('No changes when language is javascript', async () => {
			if (useCustomPropertyInMetadata) {
				sinon.stub(notebook, 'metadata').get(() => ({
					custom: {
						nbformat: 4, nbformat_minor: 5,
						metadata: {
							language_info: { name: 'javascript' }
						}
					}
				}));
			} else {
				sinon.stub(notebook, 'metadata').get(() => ({
					nbformat: 4, nbformat_minor: 5,
					metadata: {
						language_info: { name: 'javascript' }
					}
				}));
			}
			const cell: NotebookCell = {
				document: {
					languageId: 'javascript'
				} as any,
				executionSummary: {},
				index: 0,
				kind: NotebookCellKind.Code,
				metadata: useCustomPropertyInMetadata ? {
					custom: {
						id: '1234',
						metadata: {
							collapsed: true, scrolled: true
						}
					}
				} : {
					id: '1234',
					metadata: {
						collapsed: true, scrolled: true
					}
				},
				notebook,
				outputs: []
			};
			const e: NotebookDocumentChangeEvent = {
				notebook,
				metadata: undefined,
				contentChanges: [],
				cellChanges: [
					{
						cell,
						document: undefined,
						metadata: undefined,
						outputs: undefined,
						executionSummary: undefined
					}
				]
			};

			onDidChangeNotebookDocument.fire(e);

			assert.strictEqual(editsApplied.length, 0);
			assert.strictEqual(cellMetadataUpdates.length, 0);
		});
		test('Remove language from metadata when cell language matches kernel language', async () => {
			if (useCustomPropertyInMetadata) {
				sinon.stub(notebook, 'metadata').get(() => ({
					custom: {
						nbformat: 4, nbformat_minor: 5,
						metadata: {
							language_info: { name: 'javascript' }
						}
					}
				}));
			} else {
				sinon.stub(notebook, 'metadata').get(() => ({
					nbformat: 4, nbformat_minor: 5,
					metadata: {
						language_info: { name: 'javascript' }
					}
				}));
			}
			const cell: NotebookCell = {
				document: {
					languageId: 'javascript'
				} as any,
				executionSummary: {},
				index: 0,
				kind: NotebookCellKind.Code,
				metadata: useCustomPropertyInMetadata ? {
					custom: {
						id: '1234',
						metadata: {
							vscode: { languageId: 'python' },
							collapsed: true, scrolled: true
						}
					}
				} : {
					id: '1234',
					metadata: {
						vscode: { languageId: 'python' },
						collapsed: true, scrolled: true
					}
				},
				notebook,
				outputs: []
			};
			const e: NotebookDocumentChangeEvent = {
				notebook,
				metadata: undefined,
				contentChanges: [],
				cellChanges: [
					{
						cell,
						document: {
							languageId: 'javascript'
						} as any,
						metadata: undefined,
						outputs: undefined,
						executionSummary: undefined
					}
				]
			};

			onDidChangeNotebookDocument.fire(e);

			assert.strictEqual(editsApplied.length, 1);
			assert.strictEqual(cellMetadataUpdates.length, 1);
			const newMetadata = cellMetadataUpdates[0].newCellMetadata || {};
			if (useCustomPropertyInMetadata) {
				assert.strictEqual(Object.keys(newMetadata).length, 1);
				assert.strictEqual(Object.keys(newMetadata.custom).length, 2);
				assert.deepStrictEqual(newMetadata.custom.metadata, { collapsed: true, scrolled: true });
				assert.strictEqual(newMetadata.custom.id, '1234');
			} else {
				assert.strictEqual(Object.keys(newMetadata).length, 2);
				assert.deepStrictEqual(newMetadata.metadata, { collapsed: true, scrolled: true });
				assert.strictEqual(newMetadata.id, '1234');
			}
		});
		test('Update language in metadata', async () => {
			if (useCustomPropertyInMetadata) {
				sinon.stub(notebook, 'metadata').get(() => ({
					custom: {
						nbformat: 4, nbformat_minor: 5,
						metadata: {
							language_info: { name: 'javascript' }
						}
					}
				}));
			} else {

				sinon.stub(notebook, 'metadata').get(() => ({
					nbformat: 4, nbformat_minor: 5,
					metadata: {
						language_info: { name: 'javascript' }
					}
				}));
			}
			const cell: NotebookCell = {
				document: {
					languageId: 'powershell'
				} as any,
				executionSummary: {},
				index: 0,
				kind: NotebookCellKind.Code,
				metadata: useCustomPropertyInMetadata ? {
					custom: {
						id: '1234',
						metadata: {
							vscode: { languageId: 'python' },
							collapsed: true, scrolled: true
						}
					}
				} : {
					id: '1234',
					metadata: {
						vscode: { languageId: 'python' },
						collapsed: true, scrolled: true
					}
				},
				notebook,
				outputs: []
			};
			const e: NotebookDocumentChangeEvent = {
				notebook,
				metadata: undefined,
				contentChanges: [],
				cellChanges: [
					{
						cell,
						document: {
							languageId: 'powershell'
						} as any,
						metadata: undefined,
						outputs: undefined,
						executionSummary: undefined
					}
				]
			};

			onDidChangeNotebookDocument.fire(e);

			assert.strictEqual(editsApplied.length, 1);
			assert.strictEqual(cellMetadataUpdates.length, 1);
			const newMetadata = cellMetadataUpdates[0].newCellMetadata || {};
			if (useCustomPropertyInMetadata) {
				assert.strictEqual(Object.keys(newMetadata).length, 1);
				assert.strictEqual(Object.keys(newMetadata.custom).length, 2);
				assert.deepStrictEqual(newMetadata.custom.metadata, { collapsed: true, scrolled: true, vscode: { languageId: 'powershell' } });
				assert.strictEqual(newMetadata.custom.id, '1234');
			} else {
				assert.strictEqual(Object.keys(newMetadata).length, 2);
				assert.deepStrictEqual(newMetadata.metadata, { collapsed: true, scrolled: true, vscode: { languageId: 'powershell' } });
				assert.strictEqual(newMetadata.id, '1234');
			}
		});

		test('Will save event without any changes', async () => {
			await onWillSaveNotebookDocument.fireAsync({ notebook, reason: TextDocumentSaveReason.Manual }, token.token);
		});
		test('Wait for pending updates to complete when saving', async () => {
			let resolveApplyEditPromise: (value: boolean) => void;
			const promise = new Promise<boolean>((resolve) => resolveApplyEditPromise = resolve);
			applyEditStub.restore();
			sinon.stub(workspace, 'applyEdit').callsFake((edit: WorkspaceEdit) => {
				editsApplied.push(edit);
				return promise;
			});

			const cell: NotebookCell = {
				document: {} as any,
				executionSummary: {},
				index: 0,
				kind: NotebookCellKind.Code,
				metadata: {},
				notebook,
				outputs: []
			};
			const e: NotebookDocumentChangeEvent = {
				notebook,
				metadata: undefined,
				contentChanges: [
					{
						range: new NotebookRange(0, 0),
						removedCells: [],
						addedCells: [cell]
					}
				],
				cellChanges: []
			};

			onDidChangeNotebookDocument.fire(e);

			assert.strictEqual(editsApplied.length, 1);
			assert.strictEqual(cellMetadataUpdates.length, 1);

			// Try to save.
			let saveCompleted = false;
			const saved = onWillSaveNotebookDocument.fireAsync({
				notebook,
				reason: TextDocumentSaveReason.Manual
			}, token.token);
			saved.finally(() => saveCompleted = true);
			await new Promise((resolve) => setTimeout(resolve, 10));

			// Verify we have not yet completed saving.
			assert.strictEqual(saveCompleted, false);

			resolveApplyEditPromise!(true);
			await new Promise((resolve) => setTimeout(resolve, 1));

			// Should have completed saving.
			saved.finally(() => saveCompleted = true);
		});

		interface IWaitUntil {
			token: CancellationToken;
			waitUntil(thenable: Promise<unknown>): void;
		}

		interface IWaitUntil {
			token: CancellationToken;
			waitUntil(thenable: Promise<unknown>): void;
		}
		type IWaitUntilData<T> = Omit<Omit<T, 'waitUntil'>, 'token'>;

		class AsyncEmitter<T extends IWaitUntil> {
			private listeners: ((d: T) => void)[] = [];
			get event(): (listener: (e: T) => any, thisArgs?: any, disposables?: Disposable[]) => Disposable {

				return (listener, thisArgs, _disposables) => {
					this.listeners.push(listener.bind(thisArgs));
					return {
						dispose: () => {
							//
						}
					};
				};
			}
			dispose() {
				this.listeners = [];
			}
			async fireAsync(data: IWaitUntilData<T>, token: CancellationToken): Promise<void> {
				if (!this.listeners.length) {
					return;
				}

				const promises: Promise<unknown>[] = [];
				this.listeners.forEach(cb => {
					const event = {
						...data,
						token,
						waitUntil: (thenable: Promise<WorkspaceEdit>) => {
							promises.push(thenable);
						}
					} as T;
					cb(event);
				});

				await Promise.all(promises);
			}
		}
	});
});
